iT邦幫忙

2023 iThome 鐵人賽

DAY 24
0
Modern Web

深入淺出,完整認識 Next.js 13 !系列 第 24

Day 24 - 如何在表單外使用 Server Actions - formAction & useTransition

  • 分享至 

  • xImage
  •  

昨天介紹了如何在 <form> 的表單提交後,透過 action prop 觸發 Server Actions。但假如今天表單中不只一個按鈕,我希望 type='submit' 以外的按鈕能觸發其他 actions 呢?又甚至是在表單以外使用 Server Actions 呢?

假如還不知道 Server Actions 是什麼的讀者,建議先閱讀昨天的文章:Day 23 - 再多利用 Server 一點點:Route Handler & Server Actions

今天就來探索不同情境呼叫 Server Actions 的方法吧!


表單有多個按鈕

假如表單中不只一個按鈕,例如:

export default function ToDo() {
  const addTask = async () => {
    'use server';
    ...
  };

  const onSubmit = async () => {
    'use server';
    ...
  };
  return (
    <form action={onSubmit}>
      <button>Add a Task</button>
      <button type='submit'>Submit</button>
    </form>
  );
}

我們希望 Add a Task 按鈕能觸發另個 Server Action addTasks(),則可以使用 formAction prop:

export default function ToDo() {
  const addTasks = async () => {
    'use server';
    ...
  };

  const onSubmit = async () => {
    'use server';
    ...
  };
  return (
    <form action={onSubmit}>
      <button formAction={addTask}>Add a Task</button>
      <button type='submit'>Submit</button>
    </form>
  );
}

完成後,按下 Add a Task 按鈕後就會呼叫 addTask(),而按下 Submit 則會呼叫 onSubmit()

補充:formAction 除了可以用在<button> 以外,也可以用在 <input type='submit'><input type='image'> 上。

這邊要注意,假如要使用 formAction 來呼叫 Server Actions,按鈕必須包在 <form> 裡面。假如我把上面的 JSX 改成:

export default function ToDo() {
  const addTasks = async () => {
    'use server';
    ...
  };

  // 點擊按鈕後不會觸發 addTasks()
  return (
      <button formAction={addTasks}>Add a Task</button>
  );
}

這樣點擊 Add a Task 不會有效果,必須在按鈕外包一層 <form>

export default function ToDo() {
  const addTasks = async () => {
    'use server';
    ...
  };

  return (
    <form>
      <button formAction={addTasks}>Add a Task</button>
    </form>
  );
}


但假如我想在 <form> 以外的地方使用呢?

在表單以外呼叫 Server Actions

我們也可以直接透過 onClick 事件來呼叫 Server Actions。

比方說,上述的例子,我們希望在表單下加一顆按鈕,點擊後會觸發 Server Action addData(),寫一筆固定的資料進 DB,並更新畫面:

/* utils/actions.ts */
export const addData = async (data: UserData) => {
  try {
    await addDoc(collection(db, 'users'), data);
    revalidatePath('/users');  
  } catch (error) {
    console.log(error);
  }
};

我們可以直接在按鈕的 onClick 事件呼叫 addData()

/* app/users/page.tsx */
'use client';
import { addData } from '../utils/action';

export default function Form() {
  const data = {
    name: 'test',
    email: 'test@gmail.com',
    age: 20,
  };    
      
  return (
    <>
      ...
    <button onClick={() => addData(data)}>Add Data</button>
    </>
  );
}

這樣點擊 add Data 時,就會增加一筆資料進 DB,完成後也會更新畫面:
server actions demo without form

但在點擊 Add Data 後,到畫面更新仍然有個時間差。因為不是在 <form> 中觸發 Server Actions,無法透過 useFormStatus 的 pending 來判斷 actions 狀態,這樣有辦法在表單更新前,製造 loading 效果嗎?

有的!可以使用 React 的 useTransition hook。

useTransition

useTransition 也是 React 18 正式推出的 hook 之一,主要功能是劃分 state 更新的優先順位,讓某個 state 更新時,不要凍結 UI

當處理順位較低的 state 觸發的 re-render 時,假如過程中有順位較高的 state 更新,則會中斷順位較低的 state 更新,先行更新順位較高的 state 與處理 re-render。

舉例來說,今天頁面包含一個用戶清單 <UserList> 和一個計數器 <Counter><UserList> 會在使用這按下 Show Users 後觸發 state 更新,來顯示用戶名單。但因為用戶數量很多,state 更新和 re-render 需要一點時間:

'use client';
import { useState, useTransition } from 'react';

const UserList = memo(function UserList() {
    // 怕 code 太長,故省略 UserList 邏輯
    ...
});

export default function Page() {
  const [isDisplay, setIsDisplay] = useState(false);
  const [count, setCount] = useState(0);

  const showUser = () => {
    setIsDisplay(true);
  };

  return (
    <div className='...'>
      <div className='...'>
        <button className='...' onClick={showUser}>
          Show Users
        </button>
        <div className='...'>
          {isDisplay && <UserList />}
        </div>
      </div>
      <Counter count={count} setCount={setCount} />
    </div>
  );
}

一般狀況,re-render 完成前,畫面會凍結,所以 re-render 完成前我點擊計數器的 +1 按鈕,計數器會等到 <UserList> re-render 完成後才會接著 re-render:
without useTransition demo

為了不讓 <UserList> 的 re-render 讓畫面其他部分無法運作,這時候就可以使用 useTransition 中的 startTransition 包住 setIsDisplay 來降低它的更新排序。假如更新過程有觸發其他 state 更新,比方說點擊 +1 後要更新 count,會優先更新 count 並 re-render:

export default function Page() {
  ...
  const [isPending, startTransition] = useTransition();

  const showUser = () => {
    // 加入 startTransition
    startTransition(() => {
      setIsDisplay(true);
    });
  };

  return (
    <div className='...'>
      <div className='...'>
        <button className='...' onClick={showUser}>
          Show Users
        </button>
        <div className='...'>
          {isDisplay && <UserList />}
        </div>
      </div>
      <Counter count={count} setCount={setCount} />
    </div>
  );
}

從上方程式碼可以發現,useTransition return 的 array 中還有一個 isPending。顧名思義,可以讓我們判斷 transition 是否完成了。所以再提升一點 UX,我們可以用 isPending 來讓 <UserList> re-render 完成前先顯示 Loading...:

export default function Page() {
  ...
  const [isPending, startTransition] = useTransition();

  const showUser = () => {
    startTransition(() => {
      setIsDisplay(true);
    });
  };

  return (
    <div className='...'>
      <div className='...'>
        <button className={buttonStyle} onClick={showUser}>
          Show Users
        </button>
        <div className='h-[150px] overflow-auto'>
          {isDisplay && <UserList />}
          // 加入 Loading 特效
          {isPending && <div>Loading...</div>}
        </div>
      </div>
      <Counter count={count} setCount={setCount} />
    </div>
  );
}

完成後,當點擊 Show Users 時,會觸發<UserList> re-render,完成前先會顯示 Loading...,同時間點擊 +1 一樣可以正常更新計數:
useTransition demo

使用 useTransition 製造 Loading 效果

所以假如想在 <form> 以外呼叫 Server Actions,又希望畫面更新前有 loading 提示,就可以透過 useTransition。

只需要把 addData 包進 startTransition,再讓isPending 為 true 時顯示 loading UI:

/* app/users/page.tsx */
'use client';
import { useTransition } from 'react';
import { addData} from '../utils/action';


export default function Form() {
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    // 用 startTransition 包住 addData
    startTransition(() =>
      addData({
        name: 'test',
        email: 'test@gmail.com',
        age: 20,
      })
    );
  };
    
  return (
    <>
      ...
      <button onClick={handleClick}>add Data</button> 
      // isPending 為 true 則顯示 Loading...
      {isPending && <div>Loading...</div>}
    </>
  );
}

完成後,點擊 Add Data 就一樣可以觸發 Server Actions 來新增資料到 DB,並讓使用者列表重新渲染。除此之外,在畫面出現新增的用戶前,按鈕底下會有個 loading 提示:
server action with useTransition


學會怎麼使用 Server Actions 後,明天會帶大家嘗試理解 Server Actions 背後的原理,以及分享我在網路上看到一些針對 Server Actions 目前資安上的疑慮,和是否要使用的考慮因素。

謝謝大家耐心的閱讀,我們明天見!


上一篇
Day 23 - 再多利用 Server 一點點:Route Handler & Server Actions
下一篇
Day 25 - Next.js 13.5:Server Actions 原理與現行版本使用考量
系列文
深入淺出,完整認識 Next.js 13 !30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言